Every Nevie platform is deployed the same way: a Spring Boot jar at a fixed path on disk, managed by systemd, behind Nginx. No Docker, no Kubernetes, no container orchestration overhead. Just a well-understood Linux service that survives reboots, restarts cleanly on failure, and can be updated with a two-command deploy script. This is exactly how we do it.
Directory layout
Each application gets its own directory under /srv/. The jar, external config,
and website assets are intentionally separated so a content update never requires touching
the jar.
/srv/nevie/
├── app.jar ← built by ./gradlew bootJar, copied here on deploy
├── config/
│ └── application.properties ← prod config: www-root, base-url, cache settings
│ loaded via --spring.config.additional-location
└── www/ ← external content root (never inside the jar)
├── home.html
├── about.html
├── solutions.html
├── products.html
├── developer.html
├── contact.html
├── fragments/
│ └── head.html ← the one Thymeleaf fragment
├── blog/
│ └── spring-boot-on-vps/
│ ├── blog.html
│ └── images/
├── css/
├── js/
└── images/
systemd unit file
Create /etc/systemd/system/nevie.service. The
--spring.config.additional-location flag loads the external
application.properties without replacing Boot's defaults — it overlays
only the keys you specify.
[Unit]
Description=Nevie Technologies Corporate Portal
After=network.target mariadb.service
Wants=network.target
[Service]
Type=simple
User=nevie
Group=nevie
WorkingDirectory=/srv/nevie
ExecStart=/usr/bin/java \
-Xms128m -Xmx256m \
-jar /srv/nevie/app.jar \
--spring.config.additional-location=file:/srv/nevie/config/
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nevie
[Install]
WantedBy=multi-user.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable nevie
sudo systemctl start nevie
sudo journalctl -u nevie -f # tail live logs
Nginx reverse proxy
Nginx handles SSL termination, gzip compression, and static file headers.
Spring Boot only sees plain HTTP on 127.0.0.1:8080.
The X-Forwarded-* headers let Spring correctly construct
redirect URLs and canonical links when it's behind the proxy.
server {
listen 80;
server_name nevie.xyz www.nevie.xyz;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name nevie.xyz www.nevie.xyz;
ssl_certificate /etc/letsencrypt/live/nevie.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nevie.xyz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/javascript
application/json image/svg+xml;
# Static assets: long cache (Spring Boot serves these with ETag support)
location ~* \.(css|js|png|jpg|jpeg|gif|ico|woff2|woff|ttf|svg)$ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
}
# Everything else through Spring Boot
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
Firewall with UFW
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Port 8080 is intentionally not opened externally — only Nginx reaches Spring Boot directly.
Zero-downtime deploy
Because the jar path is fixed and systemd restarts the process cleanly,
a full application deploy is three commands. systemd's Restart=on-failure
means the service recovers automatically from a bad jar (the old process is still
running until the new one starts). Content-only updates (editing HTML in
www/) need no restart at all — the filesystem template resolver
picks up changes within the configured cache TTL.
#!/bin/bash
# deploy.sh — run from project root after a successful build
set -e
JAR=build/libs/app.jar
echo "Building..."
./gradlew bootJar -q
echo "Uploading jar..."
scp "$JAR" nevie@vps:/srv/nevie/app.jar
echo "Restarting service..."
ssh nevie@vps "sudo systemctl restart nevie"
echo "Tailing logs (Ctrl-C to exit)..."
ssh nevie@vps "sudo journalctl -u nevie -f --lines=40"
Production application.properties
Stored at /srv/nevie/config/application.properties — never committed
to the repository. Overrides the dev defaults bundled in the jar.
nevie.content.www-root=/srv/nevie/www
nevie.content.blog-dir=/srv/nevie/www/blog
nevie.content.fragments-dir=/srv/nevie/www/fragments
nevie.content.template-cache-ttl-ms=60000
nevie.site.base-url=https://nevie.xyz
spring.thymeleaf.cache=true
server.compression.enabled=true
spring.web.resources.cache.cachecontrol.max-age=31536000
spring.web.resources.cache.cachecontrol.no-cache=false
logging.level.com.gsq.nevie=INFO
Why not Docker?
For a set of independently deployed Spring Boot services on a single VPS, the overhead of a container runtime adds complexity without adding isolation we don't already get from running separate systemd services as separate OS users. If the deployment target changes to a multi-node cluster, Docker or Podman makes sense. For a VPS running three or four focused services, systemd is simpler, faster to debug, and requires no daemon.